/*
 * Copyright 2011-2012 by The Regents of the University of California Licensed
 * under the Apache License, Version 2.0 (the "License"); you may not use this
 * file except in compliance with the License. you may obtain a copy of the
 * License from
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package edu.ucsd.db.bassarisk.query.parser;

import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Stack;
import java.util.logging.Logger;

import org.apache.commons.lang3.tuple.Pair;

import edu.ucsd.db.bassarisk.exception.ParserException;
import edu.ucsd.db.bassarisk.query.ast.SyntaxExpressions;
import edu.ucsd.db.bassarisk.query.ast.SyntaxExpressions.NonterminalSyntaxExpression;
import edu.ucsd.db.bassarisk.query.ast.SyntaxExpressions.SyntaxExpression;

public class Parser {
  /**
   * This class encapsulates the various parameters of a non-completed
   * non-terminal expression in the query language.
   */
  private static class NonterminalContext {
    List<String> args;
    String name;
    int startColumn;
    List<SyntaxExpression> subExprs;

    public NonterminalContext(final String name, final int startColumn) {
      this.name = name;
      this.startColumn = startColumn;
      this.args = new ArrayList<String>();
      this.subExprs = new ArrayList<SyntaxExpression>();
    }
  }

  /**
   * The sorts of tokens that the parser can think it is parsing. If the parser
   * is looked at as a state machine, then these are the possible states.
   */
  private static enum TokenSort {
    empty,
    keyword,
    namedKeyword,
    predicateArg,
    predicateName
  }

  private int column;
  private String currentToken;
  private TokenSort currentTokenSort;
  private int currentTokenStartColumn;
  private final List<Pair<String, String>> keywordBindings;
  private final Logger LOG = Logger.getLogger(this.getClass().getName());
  private final Stack<NonterminalContext> openOperators;

  public Parser() {
    this.keywordBindings = new ArrayList<Pair<String, String>>();
    this.openOperators = new Stack<NonterminalContext>();
  }

  private void finalizeContext() throws ParserException {
    if (this.openOperators.size() <= 1) { // The QUERYWRAPPER expression remains
                                          // open
      String msg = "Unmatched close paren at column %s. Ignoring.";
      this.LOG.warning(String.format(msg, this.column));
      return;
    }
    NonterminalContext nc = this.openOperators.pop();
    NonterminalSyntaxExpression newOp =
        SyntaxExpressions.createNonterminalExpression(nc.name, 1, nc.startColumn,
                                                      nc.args, nc.subExprs);
    this.openOperators.peek().subExprs.add(newOp);
  }

  private void finalizeToken() throws ParserException {
    int kwId = -1;
    String kwName;
    try {
      switch (this.currentTokenSort) {
      case empty: // do nothing
        return;
      case predicateName: // create a new context on the stack
        this.openOperators.add(new NonterminalContext(this.currentToken
            .toUpperCase(), this.currentTokenStartColumn));
        break;
      case predicateArg:
        this.openOperators.peek().args.add(this.currentToken);
        this.currentToken = "";
        break;
      case keyword: // add keyword to bindings, reference to current context
        kwId = this.keywordBindings.size();
        kwName = "kw" + kwId;
        this.keywordBindings.add(Pair.of(kwName, this.currentToken));
        this.openOperators.peek().subExprs.add(SyntaxExpressions
            .createKeywordExpression(this.currentToken, false, null, 1,
                                     this.currentTokenStartColumn));
        break;
      case namedKeyword:
        String[] parts = this.currentToken.split("\\$");
        assert parts.length == 2;
        String kwToken = parts[0];
        kwName = parts[1];
        this.openOperators.peek().subExprs.add(SyntaxExpressions
            .createKeywordExpression(kwToken, true, kwName, 1,
                                     this.currentTokenStartColumn));
        break;
      default:
        // Encountered unexpected TokenSort name
        String message = "Internal Parser Error";
        throw new ParserException(message);
      }
    } finally {
      this.currentToken = "";
      this.currentTokenSort = TokenSort.empty;
    }
  }

  public SyntaxExpression parse(final String query) throws ParserException {
    this.reset();
    for (this.column = 0; this.column < query.length(); ++this.column) {
      char ch = query.charAt(this.column);
      if (Character.isWhitespace(ch)) {
        this.finalizeToken();
        assert this.currentTokenSort == TokenSort.empty;
        assert this.currentToken.equals("");
      } else if (Character.isLetterOrDigit(ch)) {
        if (this.currentTokenSort == TokenSort.empty) {
          // assume new token is a keyword until there is evidence otherwise
          this.currentTokenSort = TokenSort.keyword;
          this.currentTokenStartColumn = this.column;
        }
        this.currentToken += ch;
      } else {
        // handle special characters
        switch (ch) {
        case '_':
          this.currentToken += ch;
          break;
        case ':': // Argument separator
          this.requireToken(':', EnumSet.of(TokenSort.keyword,
                                            TokenSort.predicateName,
                                            TokenSort.predicateArg));
          if (this.currentTokenSort == TokenSort.keyword) {
            // The current token is a predicate name, not a keyword
            this.currentTokenSort = TokenSort.predicateName;
          }
          this.finalizeToken();
          this.currentTokenSort = TokenSort.predicateArg;
          break;
        case '$': // Named keyword
          this.requireToken('$', EnumSet.of(TokenSort.keyword));
          assert this.currentToken.length() > 0; // requirement of
                                                 // TokenSort.keyword
          this.currentTokenSort = TokenSort.namedKeyword;
          this.currentToken += ch;
          break;
        case '(': // Opens new context
          this.requireToken('(', EnumSet.of(TokenSort.keyword,
                                            TokenSort.predicateName,
                                            TokenSort.predicateArg));
          if (this.currentTokenSort == TokenSort.keyword) {
            // The current token is a predicate name, not a keyword
            this.currentTokenSort = TokenSort.predicateName;
          }
          this.finalizeToken();
          break;
        case ')': // Closes top context
          this.finalizeToken();
          this.finalizeContext();
          break;
        default: // unexpected token
          String message = "Unexpected token %s, treating as whitespace.";
          this.LOG.warning(String.format(message, ch));
          this.finalizeToken();
          assert this.currentTokenSort == TokenSort.empty;
          assert this.currentToken.equals("");
        }
      }
    }
    this.finalizeToken();
    if (this.openOperators.size() > 1) {
      String message = "Detected unclosed parenthsis.";
      this.LOG.warning(message);
      while (this.openOperators.size() > 1) {
        this.finalizeContext();
      }
    }
    NonterminalContext nc = this.openOperators.pop();
    SyntaxExpression root = null;
    if (nc.subExprs.size() == 1) {
      root = nc.subExprs.get(0);
    } else {
      root =
          SyntaxExpressions.createNonterminalExpression("AND", 1, 0,
                                                        new ArrayList<String>(),
                                                        nc.subExprs);
    }
    return root;
  }

  private void requireToken(final char ch, final EnumSet<TokenSort> validSorts)
      throws ParserException {
    if (!validSorts.contains(this.currentTokenSort)) {
      String msg =
          "Character %s invalid in context of %s at column %s, requires context of %s.";
      msg = String.format(msg, ch, this.currentTokenSort, this.column, validSorts);
      this.LOG.severe(msg);
      throw new ParserException(msg);
    }
  }

  private void reset() {
    this.currentToken = "";
    this.currentTokenSort = TokenSort.empty;
    this.keywordBindings.clear();
    this.openOperators.clear();
    this.openOperators.add(new NonterminalContext("QUERYWRAPPER", 0));
  }
}
